contents
컴퓨터는 0.1이나 3.14와 같은 대부분의 십진수를 부동 소수점 산술이라는 근사치 계산법을 사용하여 계산합니다. 컴퓨터의 CPU는 이진수(2진법)를 기반으로 작동하기 때문에, 대부분의 10진법 분수를 완벽하게 표현할 수 없습니다.
핵심 문제: 2진법 vs 10진법
컴퓨터는 모든 것을 비트(0과 1)로 저장합니다.
- 10진법(십진수) 에서 분수는 10의 거듭제곱($1/10$, $1/100$ 등)을 기반으로 합니다.
- 2진법(이진수) 에서 분수는 2의 거듭제곱($1/2$(0.5), $1/4$(0.25), $1/8$(0.125) 등)을 기반으로 합니다.
문제는 10진법에서는 흔한 많은 분수들이 2진법에서는 완벽하게 표현될 수 없다는 것입니다. 가장 유명한 예가 0.1입니다.
0.5는 2진수로0.1입니다.0.25는 2진수로0.01입니다.0.1(즉, $1/10$)을 2진수로 변환하면0.0001100110011...이 됩니다. 이는 마치 10진법에서 $1/3$이 0.333...으로 반복되는 것처럼, 2진법에서 무한히 반복됩니다.
컴퓨터는 저장 공간이 유한하기 때문에 이 숫자를 특정 지점에서 잘라내야 하고, 결국 0.1의 _근사치_를 저장하게 됩니다. 이것이 모든 부동 소수점 "부정확성"의 근본 원인입니다.
해결책: 부동 소수점 (과학적 표기법)
이러한 숫자들을 다루기 위해 컴퓨터는 본질적으로 2진수 과학적 표기법인 시스템을 사용합니다. 이 표준을 IEEE 754라고 합니다.
비유 (과학적 표기법):
10진법에서 우리는 1,234,500,000과 같은 거대한 숫자를 $1.2345 \times 10^9$로 씁니다.
여기에는 세 부분이 있습니다.
- 부호: 양수
- 유효숫자 (또는 가수): 1.2345
- 지수: 9
컴퓨터의 방식 (IEEE 754):
64비트 "double" (십진수를 나타내는 일반적인 타입)은 숫자를 정확히 같은 방식으로 저장하되, 2진법을 사용합니다. 64비트를 세 부분으로 나눕니다.
- 부호 비트 (1 비트):
0은 양수,1은 음수. - 지수 (11 비트): 2의 "거듭제곱"을 저장합니다. 이는 숫자의 범위(얼마나 크거나 작은지)를 결정합니다.
- 가수/유효숫자 (52 비트): 숫자의 정밀도 (
1.000110011...부분)를 저장합니다.
따라서 0.1은 다음과 같은 근사치로 저장됩니다.
+(1.100110011001... \times 2^{-4})
CPU가 부동 소수점을 계산하는 방법
CPU에 A + B를 계산하라고 요청하면, ALU(산술 논리 장치) 는 다음 단계를 수행합니다.
- 분해: CPU의 FPU(부동 소수점 장치)가
A와B를 각각 부호, 지수, 가수의 세 부분으로 분해합니다. - 지수 정렬: 두 숫자를 더하려면 "거듭제곱"(지수)이 같아야 합니다. CPU는 더 작은 지수를 가진 숫자의 가수를 큰 지수와 일치할 때까지 시프트(shift)합니다.
- 가수 덧셈: CPU의 가산기 회로가 두 가수 값을 더합니다.
- 재정규화: 결과를 다시 표준적인 부호/지수/가수 형식으로 변환하며, 이 과정에서 결과를 시프트하고 지수를 다시 조정할 수 있습니다.
유명한 부정확성: 0.1 + 0.2 != 0.3
이것이 부동 소수점 오류의 고전적인 예입니다.
- 컴퓨터는
0.1의 근사치 (0.1보다 약간 더 큰 값)를 저장합니다. 0.2의 근사치 (0.2보다 약간 더 작은 값)를 저장합니다.- CPU가 이 두 부정확한 근사치를 더하면, 작은 오류들이 합쳐집니다.
- 그 결과는 컴퓨터가
0.3에 대해 저장하는 부정확한 근사치와 같지 않게 됩니다.
이것이 많은 프로그래밍 언어에서 0.1 + 0.2가 0.30000000000000004와 같은 값으로 나오는 이유입니다.
돈 계산을 위한 해결책: 정확성이 중요할 때 💰
부동 소수점 숫자는 근사값이기 때문에 돈 계산에는 끔찍합니다. 금융 계산에 절대 사용해서는 안 됩니다.
대신 컴퓨터는 두 가지 다른 방법을 사용합니다.
- 고정 소수점 산술: 가장 일반적인 해결책입니다. 돈을 센트와 같은 가장 작은 단위를 나타내는 정수로 저장합니다.
$19.99를 저장하기 위해 정수1999를 저장합니다.$5.00를 저장하기 위해500을 저장합니다.$19.99 + $5.00계산은 완벽하게 정확한 정수 덧셈1999 + 500 = 2499가 됩니다.- 오직 화면에 표시할 때만 십진수(
24.99)로 형식을 변환합니다.
- Decimal 데이터 타입: 일부 언어는 특별한 타입을 제공합니다 (자바의
BigDecimal이나 파이썬의Decimal등). 이러한 타입들은 2진 근사치를 사용하지 않습니다. 대신, 마치 사람이나 간단한 계산기가 하듯이, 숫자를 10진수 숫자 목록으로 저장하여 CPU의 고속 계산을 희생하는 대신 완벽한 10진수 정확도를 얻습니다.
1.238572983 + 4.29834759823674981 || pi + pi * 1.5
두 계산 모두 CPU의 FPU(부동 소수점 장치) 에 의해 처리되지만, 서로 다른 문제에 부딪히게 됩니다.
1. 정밀도의 문제: 1.238572983 + 4.29834759823674981
이 계산은 전적으로 정밀도에 관한 문제입니다.
- 문제점: 두 번째 숫자(
4.29...)는 18개의 10진수 자릿수를 가집니다. 가장 일반적인 10진수 타입인 64비트double은 약 15~16개의 유효 숫자만 저장할 수 있습니다. - 발생하는 일:
4.29834759823674981을 표준double변수에 저장하려고 하면, 이 숫자는 표현 가능한 가장 가까운 수로 반올림됩니다. 마지막 몇 자리 숫자(...74981)는 손실되거나 변경됩니다. - 계산: CPU의 FPU는 첫 번째 숫자(
1.23...)와 두 번째 숫자의 _반올림된 근사값_을 더합니다. 그 결과는 실제 값과 매우 가깝겠지만, 계산이 시작되기도 전에 이미 정밀도를 잃었기 때문에 완벽하게 정확하지는 않을 것입니다.
이 계산을 정확하게 하려면?
이 계산을 올바르게 수행하려면, 표준 하드웨어 가속 부동 소수점 타입(float 또는 double)을 사용할 수 없습니다.
대신 자바의 BigDecimal이나 파이썬의 Decimal과 같은 특수 소프트웨어 기반 타입을 사용해야 합니다. 이러한 타입들은 숫자를 10진수 숫자 목록으로 저장하고, 완벽한 10진수 정밀도를 보장하기 위해 종이에서 계산하는 것처럼 더 느린 소프트웨어 기반의 "긴 수학(long math)"을 수행합니다.
2. 표현의 문제: pi + pi * 1.5
이 계산은 전적으로 표현에 관한 문제입니다.
- 문제점: $\pi$(파이)는 무리수입니다. 무한하고 반복되지 않는 10진수(
3.14159265...)를 가집니다. "파이"를 유한한 비트 수에 완벽하게 저장하는 것은 수학적으로 불가능합니다. - 발생하는 일:
Math.PI(또는math.pi)를 쓸 때, $\pi$의 참값을 얻는 것이 아닙니다. 64비트 내에서 가능한 가장 가까운double근사값(예:3.141592653589793)을 얻는 것입니다. - 계산: CPU의 FPU는 다음 단계를 수행합니다.
- $\pi$의
double근사값을 로드합니다. 1.5의double표현을 로드합니다 (1.5는0.1과 달리 2진수로 완벽하게 표현 가능합니다).- 두 근사값을 곱합니다.
- 그 결과에 원래 $\pi$의 근사값을 더합니다.
- $\pi$의
- 결과: 최종 답은 매우 훌륭하지만 여전히 근사치인
double값이 됩니다. 오류는 애초에 $\pi$를 컴퓨터로 표현하려는 순간부터 시작됩니다.
요약: 속도 vs. 정확성
pi + pi * 1.5의 경우: $\pi$가 무리수이기 때문에 빠른 CPU 하드웨어(FPU)가 근사값으로 연산을 수행합니다. 이는 극도의 속도가 필요하고 아주 작은 근사 오류는 허용되는 과학, 그래픽, 공학 분야에 완벽합니다.1.238572983 + 4.29834759823674981의 경우: 만약 (돈 계산처럼) 완벽한 정확도가 필요하다면, 빠른 CPU 하드웨어(FPU)를 피하고 소프트웨어에서 계산을 수행하는 특수Decimal타입을 사용해야 합니다.
이 숫자들을 정확하게 계산하려면, CPU에 내장된 float나 double 타입 대신 소프트웨어 기반의 Decimal 라이브러리를 사용해야 합니다.
이러한 라이브러리들은 2진법(binary)이 아닌, 마치 종이에 계산하듯 10진법(decimal)으로 수학을 수행하여 하드웨어의 한계를 피합니다.
가장 일반적인 예는 다음과 같습니다.
- Java:
java.math.BigDecimal - Python:
decimal.Decimal클래스 - C#:
System.Decimal
작동 원리
핵심적인 차이점은 다음과 같습니다.
double(하드웨어): 64비트에 숫자를 2진 근사값으로 저장합니다. 매우 빠르지만 정밀도 한계가 있으며0.1을 완벽하게 표현할 수 없습니다.BigDecimal(소프트웨어): 숫자를 숫자 배열 (예:[1, 2, 3, 4, 5])과 소수점 위치를 나타내는 별도의 정수로 저장합니다. 이는 훨씬 느리지만 10진수 숫자에 대해 완벽하게 정확합니다.
매우 중요한 규칙: 완벽한 정확도를 얻으려면, 이 객체들을 반드시 문자열(string) 로 생성해야 합니다. 만약 double 타입에서 생성한다면, 부정확함이 이미 발생한 상태입니다.
new BigDecimal(0.1)(잘못됨):0.1의 _부정확한double표현_을 전달하는 것입니다.new BigDecimal("0.1")(올바름): 라이브러리가 정확한 10진수 표현으로 파싱할 수 있는 문자열을 전달하는 것입니다.
예제
요청하신 계산을 정확하게 수행하는 방법은 다음과 같습니다.
1. 정밀도 문제: 1.238572983 + 4.29834759823674981
이 숫자는 18자리의 소수점을 가지며, 이는 표준 double이 담을 수 있는 것보다 많습니다.
Java (BigDecimal)
import java.math.BigDecimal;
// 완벽한 정밀도를 위해 문자열 생성자 사용
BigDecimal a = new BigDecimal("1.238572983");
BigDecimal b = new BigDecimal("4.29834759823674981");
BigDecimal result = a.add(b);
// 결과는 완벽하게 정확합니다.
System.out.println(result);
// 출력: 5.53692058123674981
Python (Decimal)
from decimal import Decimal
# 문자열 생성자 사용
a = Decimal("1.238572983")
b = Decimal("4.29834759823674981")
result = a + b
# 결과는 완벽하게 정확합니다.
print(result)
# 출력: 5.53692058123674981
2. 표현의 문제: pi + pi * 1.5
$\pi$(파이)는 무리수이므로 절대 정확하게 표현될 수 없습니다. 하지만 double이 제공하는 것보다 훨씬 넘어서는, _필요한 만큼의 정밀도_로 계산할 수 있습니다.
먼저 원하는 "계산 문맥" 즉, 정밀도를 설정합니다.
Python (Decimal)
from decimal import Decimal, getcontext
# 원하는 정밀도를 100자리로 설정
getcontext().prec = 100
# 'pi'와 '1.5'를 고정밀도 Decimal로 로드
pi = Decimal('3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679')
one_point_five = Decimal('1.5')
result = pi + (pi * one_point_five)
print(result)
# 출력은 100자리까지 정확합니다:
# 7.85398163397448309615660845819875721049292349843776455243736148076954101571552249657008706335529267
트레이드오프: 속도 vs 정확성
우리가 모든 것에 BigDecimal을 사용하지 않는 간단한 이유가 있습니다: 속도입니다.
| 타입 | 계산 주체 | 속도 | 사용 사례 |
|---|---|---|---|
double |
CPU 하드웨어 (FPU) | 매우 빠름 | 과학, 그래픽, 게임 (작은 오류가 중요하지 않은 경우) |
BigDecimal |
소프트웨어 | 매우 느림 | 금융, 돈, 결제 (100% 정확도가 필요한 경우) |
| references |